TDD實戰練習第一篇,介紹了:
TDD實戰練習第二篇則介紹了:
TDD實戰練習第三篇則介紹了:
接下來這一篇文章,將建立Authentication的單元測試,來保護當「相依物件的實作細節或相關需求改變」時,Authentication物件的商業邏輯,仍能被正常測試到。而context端也會套用strategy pattern與factory pattern。
當全部重構完成後,我們一整個ATDD/BDD/TDD的流程也就告一段落了。
雖然已經30天了,最後筆者會再整理一篇,當作是整個系列的目錄以及補充一些不錯的參考資料。
上一篇文章:[Day 29]TDD實戰練習-3
本系列文章專區
@目前的程式碼
Authentication的scenario如下所示:
@Authentication
Feature: Authentication
In order to 驗證登入資訊是否合法
As a 呼叫端物件
I want to 取得存放資料,驗證登入資訊是否吻合
Scenario: 驗證成功:當Id為1234時,輸入密碼為91時,回傳true
Given id為"1234"
And password為"91"
When 呼叫Verify
Then 回傳"true"
Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,回傳false
Given id為"1234"
And password為"1234"
When 呼叫Verify
Then 回傳"false"
Authentication測試程式,程式碼如下所示:
[Binding]
public class AuthenticationSteps
{
private static Authentication target;
[BeforeScenario("Authentication")]
public static void BeforeFeatureAuthentication()
{
target = new Authentication(new MyHash(), new CardDao());
ScenarioContext.Current.Clear();
}
[AfterScenario("Authentication")]
public static void AfterFeatureAuthentication()
{
ScenarioContext.Current.Clear();
}
[Given(@"id為""(.*)""")]
public void GivenId為(string id)
{
ScenarioContext.Current.Add("id", id);
}
[Given(@"password為""(.*)""")]
public void GivenPassword為(string password)
{
ScenarioContext.Current.Add("password", password);
}
[When(@"呼叫Verify")]
public void When呼叫Verify()
{
var id = ScenarioContext.Current["id"].ToString();
var password = ScenarioContext.Current["password"].ToString();
var result = target.Verify(id, password);
ScenarioContext.Current.Add("result", result);
}
[Then(@"回傳""(.*)""")]
public void Then回傳(string result)
{
var isValid = Convert.ToBoolean(result);
var actual = Convert.ToBoolean(ScenarioContext.Current["result"]);
Assert.AreEqual(isValid, actual);
}
}
這是屬於整合測試的部份,因為測試目標Authentication,是直接使用MyHash與CardDao。
Authentication的production code,程式碼如下:
public class Authentication
{
private IHash _hash;
private ICardDao _cardDao;
public Authentication(IHash hash, ICardDao cardDao)
{
this._hash = hash;
this._cardDao = cardDao;
}
public bool Verify(string id, string password)
{
string passwordFromDao = this.GetPasswordFromCardDao(id);
string passwordAfterHash = this.GetHash(password);
var isValid = passwordFromDao == passwordAfterHash;
return isValid;
}
private string GetHash(string password)
{
var result = this._hash.GetHash(password);
return result;
}
private string GetPasswordFromCardDao(string id)
{
var password = this._cardDao.GetPassword(id);
return password;
}
}
@建立Authentication的單元測試
雖然Authentication已經有整合測試保護了,但以Business Object來說,還是有為其建立單元測試的必要性。
一來這樣才能有單元測試的好處,二來粒度越細的測試程式越穩定,也越能發揮迴歸測試的效果。
只要我們幫Authentication建立了單元測試,那麼要驗證Verify的商業邏輯是否符合預期與使用者的需求,就完全不需要考慮到MyHash與CardDao的實作內容,甚至沒有這兩個物件,單元測試仍能運作。
首先,我們先建立一個單元測試的project,並新增一個Authentication的feature檔,並加入對應的scenario。如下圖所示:
這邊要注意一點,在scenario上,我們加上了幾個原本整合測試上沒有的東西:
這也是單元測試被稱為白箱測試的原因,在測試一個行為時,除了物件本身的邏輯以外,任何外部相依的部份,都應該被模擬物件隔開,以達到單元測試目標物件的獨立性。
以這例子來說,ICardDao的實作,資料來源從哪來,怎麼存取,Authentication根本不在意。IHash怎麼取得Hash運算之後的結果,透過什麼演算法來運作,Authentication根本不在意。
Authentication在意的只有一點:Verify本身的邏輯內容,是否符合使用這個物件的預期。
接下來,依據scenario,自動產生step檔案後,來撰寫我們的單元測試程式碼。這邊會運用到前面文章所提及的stub技巧,細節部分讀者可以參考前面的文章:[Day 7]Unit Test - Stub, Mock, Fake簡介
程式碼如下:
[Binding]
public class AuthenticationSteps
{
private static Authentication target;
private static IHash hashStub;
private static ICardDao cardDaoStub;
[BeforeScenario("Authentication")]
public static void BeforeScenarioAuthentication()
{
hashStub = MockRepository.GenerateStub<IHash>();
cardDaoStub = MockRepository.GenerateStub<ICardDao>();
target = new Authentication(hashStub, cardDaoStub);
ScenarioContext.Current.Clear();
}
[AfterScenario("Authentication")]
public static void AfterScenarioAuthentication()
{
hashStub = null;
cardDaoStub = null;
ScenarioContext.Current.Clear();
}
[Given(@"輸入id為""(.*)""")]
public void Given輸入Id為(string id)
{
ScenarioContext.Current.Add("id", id);
}
[Given(@"輸入password為""(.*)""")]
public void Given輸入Password為(string password)
{
ScenarioContext.Current.Add("password", password);
}
[Given(@"ICardDao回傳""(.*)""")]
public void GivenICardDao回傳(string password)
{
cardDaoStub.Stub(x => x.GetPassword(Arg<string>.Is.Anything)).Return(password);
}
[Given(@"IHash回傳""(.*)""")]
public void GivenIHash回傳(string hashResult)
{
hashStub.Stub(x => x.GetHash(Arg<string>.Is.Anything)).Return(hashResult);
}
[When(@"呼叫Verify")]
public void When呼叫Verify()
{
var id = ScenarioContext.Current["id"].ToString();
var password = ScenarioContext.Current["password"].ToString();
var result = target.Verify(id, password);
ScenarioContext.Current.Add("result", result);
}
[Then(@"回傳""(.*)""")]
public void Then回傳(string expected)
{
var isValid = Convert.ToBoolean(expected);
var actual = Convert.ToBoolean(ScenarioContext.Current["result"]);
Assert.AreEqual(isValid, actual);
}
}
值得留意的就是透過RhinoMocks來產生Stub的部份。
一樣依照scenario中的描述,來給定預期的回傳值。
跑一下測試結果,全數通過測試,如下圖所示:
@單元測試與整合測試的先後順序
這邊先提醒讀者一下,其實依照筆者的開發順序,依照這個Authentication的例子,其實筆者會先完成Authentication的單元測試後,才接著完成CardDao與MyHash兩個物件的TDD流程。
但這已經是有過相關經驗之後,tuning完流程的結果,原因是,當需要一個物件時,完成物件中context的商業邏輯後,就要能夠通過測試,而不必考慮其相依物件。
anyway, 讀者如果開始使用TDD一段時間後,大概就能體會每個物件可以獨立測試的樂趣,以及開發人員的協同合作,只需要透過介面溝通,就可以平行開發的快感。
@Strategy Pattern與Factory Pattern的運用
接下來,我們快速的把Login.aspx.cs透過DIP的原則,相依於Authentication的介面,並將生成物件的動作,交給factory類別來負責。
有興趣了解細節的讀者,請參考前面的文章:
@第一步,改成相依於Authentication介面
一樣,只是把宣告的部份,換成interface。程式碼如下:
protected void btnLogin_Click(object sender, EventArgs e)
{
var id = this.txtCardId.Text.Trim();
var password = this.txtPassword.Text;
IAuthentication authentication = new Authentication(new MyHash(), new CardDao());
bool isValid = authentication.Verify(id, password);
if (isValid)
{
LoginSuccess();
}
else
{
LoginFailed();
}
}
@第二步,工廠物件的TDD
一樣先用簡單工廠,先把context改成相依於工廠類別,程式碼如下:
protected void btnLogin_Click(object sender, EventArgs e)
{
var id = this.txtCardId.Text.Trim();
var password = this.txtPassword.Text;
//IAuthentication authentication = new Authentication(new MyHash(), new CardDao());
IAuthentication authentication = RepositoryFactory.GetIAuthentication();
bool isValid = authentication.Verify(id, password);
if (isValid)
{
LoginSuccess();
}
else
{
LoginFailed();
}
}
筆者的習慣,工廠類別我是直接建立單元測試,而沒有透過scenario來建立測試程式。
RepositoryFactory測試程式碼如下:
[TestMethod()]
public void GetIAuthenticationTest()
{
IAuthentication expected = new Authentication(null, null);
IAuthentication actual;
actual = RepositoryFactory.GetIAuthentication();
Assert.AreEqual(expected.GetType(), actual.GetType());
}
因為工廠內容還沒有實作,所以現在是紅燈。
接下來實作工廠內容,並通過測試。程式碼如下:
public class RepositoryFactory
{
public static Interface.BLL.IAuthentication GetIAuthentication()
{
ICardDao cardDao = GetCardDao();
IHash hash = GetHash();
return new Authentication(hash, cardDao);
}
private static IHash GetHash()
{
return new MyHash();
}
private static ICardDao GetCardDao()
{
return new CardDao();
}
}
目前完成所有程式了,執行所有測試,確保每一種層級的測試都是綠燈。如下圖所示:
@完成的程式碼
Login.aspx.cs,程式碼如下:
protected void btnLogin_Click(object sender, EventArgs e)
{
var id = this.txtCardId.Text.Trim();
var password = this.txtPassword.Text;
IAuthentication authentication = RepositoryFactory.GetIAuthentication();
bool isValid = authentication.Verify(id, password);
if (isValid)
{
LoginSuccess();
}
else
{
LoginFailed();
}
}
/// <summary>
/// 密碼驗證錯誤
/// </summary>
private void LoginFailed()
{
this.Message.Text = @"密碼輸入錯誤";
}
/// <summary>
/// 密碼驗證成功
/// </summary>
private void LoginSuccess()
{
Response.Redirect("index.aspx");
}
Authentication程式碼如下:
public class Authentication : IAuthentication
{
private IHash _hash;
private ICardDao _cardDao;
public Authentication(IHash hash, ICardDao cardDao)
{
this._hash = hash;
this._cardDao = cardDao;
}
public bool Verify(string id, string password)
{
string passwordFromDao = this.GetPasswordFromCardDao(id);
string passwordAfterHash = this.GetHash(password);
var isValid = passwordFromDao == passwordAfterHash;
return isValid;
}
private string GetHash(string password)
{
var result = this._hash.GetHash(password);
return result;
}
private string GetPasswordFromCardDao(string id)
{
var password = this._cardDao.GetPassword(id);
return password;
}
}
CardDao與MyHash就不需要特地列上來了,因為那只是實作細節。
工廠的部份,上一段已經有完整的程式碼,這邊也不列出來。
接下來是測試案例的部份。
@測試案例
Login的Feature檔如下:
@WebBank
Feature: 登入功能
In order to 驗證身份,避免非法使用者使用系統
As a 線上使用者
I want to 驗證使用者身份
Scenario: 當提款卡Id為1234時,輸入密碼為91時,驗證成功,導到index
Given 在登入頁面
And 提款卡Id輸入"1234"
And 密碼輸入"91"
When 按下確認按鈕
Then 頁面url為"index.aspx"
Scenario: 當提款卡Id為1234時,輸入密碼為1234時,驗證失敗,出現密碼錯誤
Given 在登入頁面
And 提款卡Id輸入"1234"
And 密碼輸入"1234"
When 按下確認按鈕
Then 呈現訊息為"密碼輸入錯誤"
Authentication的整合測試的Feature檔,如下:
@Authentication
Feature: Authentication
In order to 驗證登入資訊是否合法
As a 呼叫端物件
I want to 取得存放資料,驗證登入資訊是否吻合
Scenario: 驗證成功:當Id為1234時,輸入密碼為91時,回傳true
Given id為"1234"
And password為"91"
When 呼叫Verify
Then 回傳"true"
Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,回傳false
Given id為"1234"
And password為"1234"
When 呼叫Verify
Then 回傳"false"
Authentication的單元測試Feature檔如下:
@Authentication
Feature: Authentication
In order to 驗證登入資訊是否合法
As a 呼叫端物件
I want to 取得存放資料,驗證登入資訊是否吻合
Scenario: 驗證成功:當輸入Id為1234時,輸入密碼為91時,ICardDao與IHash都回傳"abc"時,回傳true
Given 輸入id為"1234"
And 輸入password為"91"
And ICardDao回傳"abc"
And IHash回傳"abc"
When 呼叫Verify
Then 回傳"true"
Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,ICardDao回傳"abc",IHash回傳"bcd"時,回傳false
Given 輸入id為"1234"
And 輸入password為"1234"
And ICardDao回傳"abc"
And IHash回傳"bcd"
When 呼叫Verify
Then 回傳"false"
MyHash的Feature檔如下:
@MyHash
Feature: MyHash
In order to 避免密碼明碼外洩
As a Authentication物件
I want to 取得密碼hash之後的結果
Scenario: 輸入為"91",應回傳"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
Given 輸入字串為"91"
When 呼叫GetHash方法
Then 回傳Hash結果為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
CardDao的Feature檔如下:
@CardDao
Feature: CardDao
In order to 存取Card的相關資料
As a Authentictaion物件
I want to 存取Card的相關資料
Scenario: 取得id為"1234",對應的密碼應為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
Given 使用者id為"1234"
When 呼叫GetPassword的方法
Then 回傳對應密碼為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
@程式碼涵蓋率
TDD實戰練習的例子,所測出來的code coverage,如下圖所示:
除了一個防呆,是我們在測試案例中沒有描述到的情況,這時候就應該去思考,是測試案例少了必要的scenario,還是production code多了不必要的程式碼。
測試程式碼涵蓋率高達97.56%,很誇張的高吧...別再說這是不可能的事囉 :)
@結論
有了這些活著的測試案例,並且是透過DSL方式描述的feature以及scenario,不管是使用者、測試人員、開發人員,甚至是未來的維護人員,都可以透過這樣的測試案例,來了解:
最後的成品,則有下列特色:
在TDD實戰練習中的一些實作細節,都可以從前面介紹每一塊拼圖的文章中了解。而整個流程的意義與回顧,則可以參考前面的文章:[Day 26]User Story/ATDD/BDD/TDD - 總結,這篇已經介紹的相當完整。
最後,希望這一個系列,可以讓讀者朋友們真的體會到TDD的total solution。
透過每一塊拼圖的解說,透過實戰演練的完整例子,可以讓大家更有感覺,這不是一個烏托邦的世界,這樣的設計方式真的沒這麼難。如同重構系列中,每一篇文章都只是一個3分鐘就能學會的技巧,動動腦,動動手,您們也絕對可以TDD!
@Sample Code
Sample code 下載位置
恭喜鐵人鍊成
另外
我花了一千萬買你贏
你可別輸給那位神功練成的那位
XD 這是什麼哏...
(其實我一直是外圍的最大莊家?)
不過謝謝大家一路的相挺啦,今年總算也擠出一份代表作了,每一年都要嘔心瀝血一下...(就差沒妻離子散了 XD)
iT邦幫忙MVPantijava提到:
你可別輸給那位神功練成的那位
怎麼樣,怕了吧,就跟你說唄,都只會欺負小水母,這下遇到水母俠就沒辦法了吧...
Dive..Dive...Dive....
哈哈,我看到ted兄的神功練成了...
恭喜
相當棒的一系列
謝謝,真心希望可以對大家有幫助。
也真心希望,台灣資訊業的環境、文化、開發工法,可以慢慢的讓每個工程師更開心、更輕鬆的做出大家都歡喜的系統。